56 — R3F 与 Drei

Three.js Journey · Bruno Simon · 难度:简单

56 — R3F 与 Drei 课程海报

介绍

使用 R3F 创建一个 Three.js 场景已经非常简单了,但它还会变得更简单。

React 的优势之一是能够让事物可复用。R3F 充分利用了这一点,开发者们正在创建许多可以随时在你的 R3F 应用中使用的 component(称为 helpers)。

它可能与几乎任何东西有关。以下是一些示例:

其中一些 helper 可以作为 component 添加(<theHelper>),一些可以作为 hook 添加(useHelper),而且你可以在 drei(德语中的 Three,发音为 /dʁaɪ̯/)中找到它们中的大多数。

首先,你应该暂停课程并把它们全部看一遍(不开玩笑)。

你不必深入测试它们。重点在于发现它们,这样在某个时候,如果你需要其中一个,你就会知道这些可能性是存在的。否则,你可能会去重新发明轮子。

不过,我们现在就要了解其中一些,以便对其潜力有个大概的认识,并且在接下来的课程中还会用到更多。

设置

这里的设置与上一课非常相似,包含一个球体、一个立方体和一个地面。

还有一个平行光源和一个环境光源。

立方体没有动画,相机由 <orbitControls> 处理。

OrbitControls

我们要安装的第一个 helper 是 OrbitControls。

你可能会觉得我们已经有了它,确实如此,但使用这个 helper 会让事情更简单,还能让我们访问到尚未启用的功能。

在 Experience.jsx 中,移除当前的 OrbitControls、extend、<orbitControls> component 以及 useThree:

export default function Experience()
{
    return <>

        <directionalLight position={ [ 1, 2, 3 ] } intensity={ 4.5 } />
        <ambientLight intensity={ 1.5 } />

        <mesh position-x={ - 2 }>
            <sphereGeometry />
            <meshStandardMaterial color="orange" />
        </mesh>

        <mesh position-x={ 2 } scale={ 1.5 }>
            <boxGeometry />
            <meshStandardMaterial color="mediumpurple" />
        </mesh>

        <mesh position-y={ - 1 } rotation-x={ - Math.PI * 0.5 } scale={ 10 }>
            <planeGeometry />
            <meshStandardMaterial color="greenyellow" />
        </mesh>

    </>
}

起始项目本可以就这样设置,但我想让你看看它将会变得简单多少。

现在,我们需要访问所有这些 drei helper。

为此,在终端中使用 npm install @react-three/drei@9.108 安装 @react-three/drei(我们固定了版本以避免意外;你可以忽略潜在的安全漏洞警告)。

如你所见,drei 属于我们在之前课程中提到过的 @react-three 作用域。

在 Experience.jsx 中,从 @react-three/drei 导入 OrbitControls:

import { OrbitControls } from '@react-three/drei'

现在,在 JSX 中的任意位置添加 <OrbitControls>:

export default function Experience()
{
    return <>

        <OrbitControls />

        {/* ... */}

    </>
}
lesson demo video preview

看看这有多方便?

我们移除了大部分代码,只添加了两行就让 OrbitControls 运行起来了。

默认情况下,OrbitControls 还启用了 enabledDamping,这会让动画更平滑。此外,我们不必像在原生 Three.js 中那样要求它在每一帧自我更新。

如果我们想禁用阻尼,可以这样做:

<OrbitControls enableDamping={ false } />
lesson demo video preview

但我们还是保留阻尼,并移除 enableDamping 属性:

<OrbitControls />

TransformControls

TransformControls 添加了一个 gizmo,允许用户变换(位置、旋转或缩放)对象。

从 @react-three/drei 导入 TransformControls:

import { TransformControls, OrbitControls } from '@react-three/drei'

将与立方体对应的 <mesh> 包裹在 <TransformControls> 中:

<TransformControls>
    <mesh position-x={ 2 } scale={ 1.5 }>
        <boxGeometry />
        <meshStandardMaterial color="mediumpurple" />
    </mesh>
</TransformControls>

但你可能会注意到两个 bug:

修复位置

首先,让我们解决位置问题,因为我们原本期望它位于立方体上。

当把 <mesh> 写在 <TransformControls> 内部时,R3F 实际上会创建一个对象嵌套在另一个对象中。

结果就是,我们偏移了里面的那个(立方体)。

这种情况有两种解决方案。

第一种是简单地把 position-x 属性从 <mesh> 移动到 <TransformControls>:

<TransformControls position-x={ 2 }>
    <mesh scale={ 1.5 }>
        <boxGeometry />
        <meshStandardMaterial color="mediumpurple" />
    </mesh>
</TransformControls>

另一个稍微复杂一点的方案是将它们分开,再通过引用和属性重新关联起来。

把 position-x 放回 <mesh> 上,并将 <TransformControls> 分离出来,放到 <mesh> 之后:

<mesh position-x={ 2 } scale={ 1.5 }>
    <boxGeometry />
    <meshStandardMaterial color="mediumpurple" />
</mesh>

<TransformControls />

在我撰写这一课时,把 <TransformControls> 放在 <mesh> 之前是可行的,但它在课程的后面也会引发 bug。

从 react 导入 useRef:

import { useRef } from 'react'

创建一个 cube 引用:

export default function Experience()
{
    const cube = useRef()

    // ...
}

使用 ref 属性将它关联到我们的 <mesh>:

<mesh ref={ cube } position-x={ 2 } scale={ 1.5 }>

然后把它加到 <TransformControls> 的 object 属性上:

<TransformControls object={ cube } />

有点绕,但仍然高效,而且大多数时候,我们本来就需要给 mesh 添加一堆引用。

它还让立方体的 <mesh> 及其位置独立于 <TransformControl>。我们可以停用 <TransformControl> 而不产生任何副作用。

OrbitControls 冲突

我们遇到的另一个问题是,当我们拖拽 gizmo 时相机会移动。

lesson demo video preview

幸运的是,这种情况已经被处理好了,我们只需给 <OrbitControls> 添加 makeDefault:

<OrbitControls makeDefault />
lesson demo video preview

当我们与 TransformControls 交互时,OrbitControls 会自动被禁用。

模式

模式默认设置为 translate,但我们可以通过 mode 属性把它改为 rotate 或 scale:

<TransformControls object={ cube } mode="rotate" />
lesson demo video preview

但我们暂时保留 translate,方法是移除 mode 属性或把它设置为 translate:

<TransformControls object={ cube } mode="translate" />

PivotControls

TransformControls 很棒,我们已经见到它流行了相当长一段时间。但不久前,我们有了一个新的解决方案,它做着大致相同的事情,但具有不同的功能,看起来也很棒,如果你的控件不仅仅用于调试目的,还要面向最终用户——比如你想让他们能够移动家具之类的——那么这就是你可能想要的。

而这个解决方案的名字就是 PivotControls。

从 @react-three/drei 导入 PivotControls:

import { PivotControls, TransformControls, OrbitControls } from '@react-three/drei'

把橙色的球体包裹在 <PivotControls> 中:

<PivotControls>
    <mesh position-x={ - 2 }>
        <sphereGeometry />
        <meshStandardMaterial color="orange" />
    </mesh>
</PivotControls>
lesson demo video preview

PivotControls 不像 TransformControls 那样作为 group 工作。如果我们想让它位于球体的中心,就必须用 anchor 属性来改变它的位置。

anchor 属性必须是一个数组,包含在全部 3 个轴(x、y 和 z)上的位置。

给 <PivotControls> 添加 anchor 属性,并将其设置为 [ 0, 0, 0 ]:

<PivotControls anchor={ [ 0, 0, 0 ] }>
    {/* ... */}
</PivotControls>
lesson demo video preview

但它现在在哪里呢?

默认情况下,PivotControls 的可见性像场景中的任何对象一样工作,如果它位于其他对象后面就会被隐藏,而它目前在球体内部。

为了强制它渲染在场景的顶层,我们可以使用它的 depthTest 属性并将其设置为 false:

<PivotControls
    anchor={ [ 0, 0, 0 ] }
    depthTest={ false }
>
    {/* ... */}
</PivotControls>
lesson demo video preview

我们再稍微多谈谈 anchor。

我们把它设置为 [ 0, 0, 0 ],结果是枢轴位于球体的中心。

anchor 很酷的一点在于,它的值是相对于对象本身的。举个例子,把 anchor attribute 设置为 [ 0, 1, 0 ]:

<PivotControls
    anchor={ [ 0, 1, 0 ] }
    depthTest={ false }
>
    {/* ... */}
</PivotControls>

现在改变球体 <mesh> 的 scale 并将其设置为 2:

<mesh position-x={ - 2 } scale={ 2 }>

如你所见,尽管我们没有改变 anchor 属性,<PivotControls> 仍然精确地位于球体的顶部。

移除球体 <mesh> 上的 scale 属性:

<mesh position-x={ - 2 }>

我们显然可以把 anchor 设置在 y 和 z 轴上:

<PivotControls
    anchor={ [ 1, 1, 1 ] }
    depthTest={ false }
>
    {/* ... */}
</PivotControls>
lesson demo video preview

并且超出 -1, +1 的范围:

<PivotControls
    anchor={ [ 0, 2, 0 ] }
    depthTest={ false }
>
    {/* ... */}
</PivotControls>

让我们用 [ 0, 0, 0 ] 把它放回中心,让 <PivotControls> 位于中心:

<PivotControls
    anchor={ [ 0, 0, 0 ] }
    depthTest={ false }
>
    {/* ... */}
</PivotControls>

我们还可以控制它的外观,比如用 lineWidth 控制线条的粗细,用 axisColors 控制轴的颜色,或用 scale 控制大小:

<PivotControls
    anchor={ [ 0, 0, 0 ] }
    depthTest={ false }
    lineWidth={ 4 }
    axisColors={ [ '#9381ff', '#ff4d6d', '#7ae582' ] }
    scale={ 2 }
>
    {/* ... */}
</PivotControls>
lesson demo video preview

默认情况下,<PivotControls> 会具有透视效果,也就是说,离相机越远它就越小。

lesson demo video preview

我们可以改变这一点,通过把 fixed 属性设置为 true 来始终保持相同的大小,但这样做意味着 scale 将变成 PivotControls 的像素大小,而 2px 有点小,我们也应该把它调大:

<PivotControls
    anchor={ [ 0, 0, 0 ] }
    depthTest={ false }
    lineWidth={ 4 }
    axisColors={ [ '#9381ff', '#ff4d6d', '#7ae582' ] }
    scale={ 100 }
    fixed={ true }
>
    {/* ... */}
</PivotControls>
lesson demo video preview

Html

Html 会添加一个将附着到你的对象上的 DOM 元素。

从 @react-three/drei 导入 Html:

import { Html, PivotControls, TransformControls, OrbitControls } from '@react-three/drei'

在 JSX 中的任意位置添加一个 <Html>,并在其中写入你想要的任何文本:

export default function Experience()
{
    // ...

    return <>

        {/* ... */}

        <Html>Test</Html>

    </>
}
lesson demo video preview

你应该会看到“Test”文本位于场景中心,即使你横向移动相机,它也会留在那里。

我们也可以把它添加到 <mesh>、<group> 或任何继承自 Object3D 的东西上。

让我们把它放进球体 <mesh> 中:

<mesh position-x={ - 2 }>
    <sphereGeometry />
    <meshStandardMaterial color="orange" />
    <Html>That's a sphere 👍</Html>
</mesh>
lesson demo video preview

现在文本附着在球体上了。

我们可以像其他 component 那样改变它的 position 来对它进行偏移:

<Html position={ [ 1, 1, 0 ] }>That's a sphere 👍</Html>

类和样式

我们可以给它添加样式,但首先我们要添加一个 class,以便在 CSS 中定位它。

为此,添加一个 wrapperClass 属性:

<Html
    position={ [ 1, 1, 0 ] }
    wrapperClass="label"
>
    That's a sphere 👍
</Html>

在 style.css 中,可以通过定位 .label 内的 div 来添加一些 CSS:

.label > div
{
    font-family: Helvetica, Arial;
    position: absolute;
    background: #00000088;
    color: white;
    padding: 15px;
    white-space: nowrap;
    overflow: hidden;
    border-radius: 30px;
    user-select: none;
}
lesson demo video preview

我们可以用 center 属性让它以自身为中心:

<Html
    position={ [ 1, 1, 0 ] }
    wrapperClass="label"
    center
>
    That's a sphere 👍
</Html>
lesson demo video preview

DOM 元素的枢轴点现在位于它的中心。

我们可以用 distanceFactor 属性来模拟透视:

<Html
    position={ [ 1, 1, 0 ] }
    wrapperClass="label"
    center
    distanceFactor={ 8 }
>
    That's a sphere 👍
</Html>
lesson demo video preview

而当有对象位于它前面时,我们还可以用 occlude 属性把它隐藏起来。

要使用 occlude,我们首先需要获取那些可能遮挡标签的各个对象的引用。

我们已经有 cube 引用了,但还没有 sphere。

用 useRef 创建一个 sphere ref,并使用 ref 属性把它关联到 <mesh>:

export default function Experience()
{
    {/* ... */}
    const sphere = useRef()

    return <>

        {/* ... */}

        <mesh ref={ sphere } position-x={ - 2 }>
            {/* ... */}
        </mesh>

        {/* ... */}

    </>
}

现在我们就可以往 occlude 属性传入一个数组了:

<Html
    position={ [ 1, 1, 0 ] }
    wrapperClass="label"
    center
    distanceFactor={ 8 }
    occlude={ [ sphere, cube ] }
>
    That's a sphere 👍
</Html>
lesson demo video preview

标签会自动地显示和隐藏。

你可以用 Html 做更多的事情,可以在 drei 文档中深入了解,我们也会在后面的课程中使用它。

Text

我们已经知道如何创建 3D 文本,但生成 3D 文本几何体有其局限性:

一个很好的替代方案是使用 SDF 字体。

SDF

虽然使用这个 helper 并不真的需要理解该技术,但它是一个很有趣的话题。

SDF 代表 Signed Distance Field(有符号距离场),通常在片段着色器中用于绘制形状。

其思路是:我们向 SDF 形状函数发送一个 2D 或 3D 点,该函数返回这个点距离形状有多远。只要能用数学方式表达,可能的形状有很多。

先忘掉字体,想象你想在一个平面上画一个简单的圆盘。

这就是我们在 着色器图案 一课中所做的,而这将是绘制一个圆的 SDF 函数。

对于更复杂的形状(尤其是 3D 形状),它显然会变得更复杂。

Inigo Quilez(@iquilezles)写了一些很棒的文章,里面有 SDF 函数的优秀示例:

SDF 字体

对于字体来说,它甚至更复杂,因为形状无法真正用数学方式计算出来。

为了解决这个问题,开发者们编写了脚本,用于生成包含字体每个字符距离信息的纹理。

然后我们可以用这个纹理来表示我们正在绘制的片段与假想字符之间的距离。

如果距离接近某个特定值,我们就绘制文本;否则什么都不绘制。

你可能会觉得这和把文本画进纹理再使用该纹理是一回事,但它远不止如此。我们得到的值是我们正在绘制的点与字符之间的距离。

这使得很多事情成为可能,比如轻松改变字体的粗细、在字体周围添加模糊、绘制巨大的文本,以及在使用同一套字符集合的同时拥有大量文字。

你可能还会觉得,如果字体距离被编码进纹理,我们应该仍然能看到像素,但纹理像素之间的自然插值使得它几乎难以察觉,除非文本非常巨大,或者编码的字形分辨率太小。

从字体生成纹理的过程漫长而复杂。幸运的是,一些开发者已经在 Troika 库(更准确地说是 troika-three-text)中完成了大部分繁重的工作,而 drei 正在把该解决方案实现到 Text helper 中。

再说一次,尽管我们解释了它的工作原理(至少是浅层的),但使用这个 helper 并不需要了解这些。

实现

从 @react-three/drei 导入 Text:

import { Text, Html, PivotControls, TransformControls, OrbitControls } from '@react-three/drei'

并在场景中任意位置放一个 <Text>(<Text> 内部的内容将被用作文本):

export default function Experience()
{
    // ...

    return <>

        {/* ... */}

        <Text>I LOVE R3F</Text>

    </>
}

默认字体是 Roboto,来自 Google Fonts CDN,但我们可以提供另一种字体,比如 /public/ 文件夹中可用的那个:

<Text
    font="./bangers-v20-latin-regular.woff"
>
    I LOVE R3F
</Text>

请记住,放在 public/ 目录中的文件可以像它们在网站同一文件夹中那样被访问,这就是为什么我们不必在路径中写 public/。

Troika 支持 woff、ttf 和 otf 格式。如果可能,你应该使用 woff,因为它更轻量。

如果你有一个其他格式的字体,可以尝试用以下工具转换:

要寻找新字体,有很多网站,但如果你喜欢 Google Fonts 合集,你会发现它们作为 woff 有点难下载,因为 Google 字体提供的是适合你配置的格式,而且大多数时候是 woff2。

但有一个叫 Google Webfonts Helper 的网站,可以帮你下载想要的字体。

无论你为字体使用了哪种方案,如果打算在线使用任何字体,别忘了检查许可证。Google Fonts 是在 SIL Open Font License 1.1 或 Apache 许可证下提供的,这意味着你可以自由地下载和使用它们。

我们可以改变字体的 size 及其 color:

<Text
    font="./bangers-v20-latin-regular.woff"
    fontSize={ 3 }
    color="salmon"
>
    I LOVE R3F
</Text>

但 3 的 size 有点太大,所以我们把它放回 1:

<Text
    font="./bangers-v20-latin-regular.woff"
    fontSize={ 1 }
    color="salmon"
>
    I LOVE R3F
</Text>

我们也可以提供自己的材质:

<Text
    font="./bangers-v20-latin-regular.woff"
    fontSize={ 1 }
    color="salmon"
>
    I LOVE R3F
    <meshNormalMaterial />
</Text>
lesson demo video preview

但我们还是坚持用默认的那个:

<Text
    font="./bangers-v20-latin-regular.woff"
    fontSize={ 1 }
    color="salmon"
>
    I LOVE R3F
</Text>

<Text> 像场景中的大多数对象一样工作,我们可以调整它的 position、rotation 和 scale:

<Text
    font="./bangers-v20-latin-regular.woff"
    fontSize={ 1 }
    color="salmon"
    position-y={ 2 }
>
    I LOVE R3F
</Text>

也支持文本换行。我们可以用 maxWidth 属性限制宽度来查看换行效果:

<Text
    font="./bangers-v20-latin-regular.woff"
    fontSize={ 1 }
    color="salmon"
    position-y={ 2 }
    maxWidth={ 2 }
>
    I LOVE R3F
</Text>

我们还可以让文本居中:

<Text
    font="./bangers-v20-latin-regular.woff"
    fontSize={ 1 }
    color="salmon"
    position-y={ 2 }
    maxWidth={ 2 }
    textAlign="center"
>
    I LOVE R3F
</Text>

关于 Text 我们就讲到这里,但如果你想了解更多诸如 stroke、blurred blur 之类的功能,请务必查看 troika-three-text 文档。

Float

下一个功能很简单。

如果你想让某样东西漂浮起来,就像空中的气球或《回到未来》里的悬浮滑板,你可以使用 Float helper。

从 @react-three/drei 导入 Float

import { Float, Text, Html, PivotControls, TransformControls, OrbitControls } from '@react-three/drei'

把它添加到 <Text> 周围:

<Float>
    <Text
        font="./bangers-v20-latin-regular.woff"
        fontSize={ 1 }
        color="salmon"
        position-y={ 2 }
        maxWidth={ 2 }
        textAlign="center"
    >
        I LOVE R3F
    </Text>
</Float>
lesson demo video preview

然后我们可以调整它的属性,比如 speed:

<Float
    speed={ 5 }
>
    {/* ... */}
</Float>
lesson demo video preview

或者 floatIntensity:

<Float
    speed={ 5 }
    floatIntensity={ 2 }
>
    {/* ... */}
</Float>
lesson demo video preview

MeshReflectorMaterial

WebGL 中的反射总是很复杂,但 MeshReflectorMaterial 并不复杂。

从 @react-three/drei 导入 MeshReflectorMaterial:

import { MeshReflectorMaterial, Float, Text, Html, PivotControls, TransformControls, OrbitControls } from '@react-three/drei'

把地面的 <meshStandardMaterial> 替换为 <MeshReflectorMaterial>(采用 PascalCase 写法),并且不带 color 属性:

<mesh position-y={ - 1 } rotation-x={ - Math.PI * 0.5 } scale={ 10 }>
    <planeGeometry />
    <MeshReflectorMaterial />
</mesh>
lesson demo video preview

这就是在 <mesh> 上添加反射所需的全部操作。

请注意,这种技术有点局限,在非平面的 mesh 上效果不好。

让我们稍微调整一下。

把 resolution 属性设置为 512 以提升质量:

<MeshReflectorMaterial
    resolution={ 512 }
/>

把 blur 设置为 [ 1000, 1000 ],把 mixBlur 设置为 1,让反射变得模糊:

<MeshReflectorMaterial
    resolution={ 512 }
    blur={ [ 1000, 1000 ] }
    mixBlur={ 1 }
/>

添加一个值为 0.5 的 mirror 属性,让反射更清晰:

<MeshReflectorMaterial
    resolution={ 512 }
    blur={ [ 1000, 1000 ] }
    mixBlur={ 1 }
    mirror={ 0.5 }
/>

如果你想给平面添加色调,就把 color 加回来:

<MeshReflectorMaterial
    resolution={ 512 }
    blur={ [ 1000, 1000 ] }
    mixBlur={ 1 }
    mirror={ 0.5 }
    color="greenyellow"
/>

一如既往,还有很多其他属性;尽管去查看它们吧。

更进一步

我们看到了一些非常有趣的 helper,以及添加它们有多么容易。但我们还没有深入研究众多可用的 helper,甚至没有深入我们所见到的每个 helper 的参数。

在接下来的课程中,我们会使用其中一些 helper,但也会发现一堆新的 helper。但请记住把它们都看一遍,这样万一日后需要,你就知道它们的存在。

顺便说一句,如果你对新的 helper 有想法,或者你在为自己的个人项目创建 helper 并想分享,请不要犹豫,把它们提交到 drei 仓库。你只差一个 pull request 就能帮助许多开发者。